forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import * as v from 'valibot'
2import { PackageFileQuerySchema } from '#shared/schemas/package'
3import type { ReadmeResponse } from '#shared/types/readme'
4import {
5 CACHE_MAX_AGE_ONE_YEAR,
6 ERROR_PACKAGE_VERSION_AND_FILE_FAILED,
7} from '#shared/utils/constants'
8
9const CACHE_VERSION = 3
10
11// Maximum file size to fetch and highlight (500KB)
12const MAX_FILE_SIZE = 500 * 1024
13
14// Languages that benefit from import linking
15const IMPORT_LANGUAGES = new Set([
16 'javascript',
17 'typescript',
18 'jsx',
19 'tsx',
20 'vue',
21 'svelte',
22 'astro',
23])
24
25interface PackageJson {
26 dependencies?: Record<string, string>
27 devDependencies?: Record<string, string>
28 peerDependencies?: Record<string, string>
29 optionalDependencies?: Record<string, string>
30}
31
32/**
33 * Fetch package.json from jsDelivr to get dependency info
34 */
35async function fetchPackageJson(packageName: string, version: string): Promise<PackageJson | null> {
36 try {
37 const url = `https://cdn.jsdelivr.net/npm/${packageName}@${version}/package.json`
38 const response = await fetch(url)
39 if (!response.ok) return null
40 return (await response.json()) as PackageJson
41 } catch {
42 return null
43 }
44}
45
46/**
47 * Fetch file content from jsDelivr CDN.
48 */
49async function fetchFileContent(
50 packageName: string,
51 version: string,
52 filePath: string,
53): Promise<string> {
54 const url = `https://cdn.jsdelivr.net/npm/${packageName}@${version}/${filePath}`
55 const response = await fetch(url)
56
57 if (!response.ok) {
58 if (response.status === 404) {
59 throw createError({ statusCode: 404, message: 'File not found' })
60 }
61 throw createError({
62 statusCode: 502,
63 message: 'Failed to fetch file from jsDelivr',
64 })
65 }
66
67 // Check content-length header if available
68 const contentLength = response.headers.get('content-length')
69 if (contentLength && parseInt(contentLength, 10) > MAX_FILE_SIZE) {
70 throw createError({
71 statusCode: 413,
72 message: `File too large (${(parseInt(contentLength, 10) / 1024 / 1024).toFixed(1)}MB). Maximum size is ${MAX_FILE_SIZE / 1024}KB.`,
73 })
74 }
75
76 const content = await response.text()
77
78 // Double-check size after fetching (in case content-length wasn't set)
79 if (content.length > MAX_FILE_SIZE) {
80 throw createError({
81 statusCode: 413,
82 message: `File too large (${(content.length / 1024 / 1024).toFixed(1)}MB). Maximum size is ${MAX_FILE_SIZE / 1024}KB.`,
83 })
84 }
85
86 return content
87}
88
89/**
90 * Returns syntax-highlighted HTML for a file in a package.
91 *
92 * URL patterns:
93 * - /api/registry/file/packageName/v/1.2.3/path/to/file.ts
94 * - /api/registry/file/@scope/packageName/v/1.2.3/path/to/file.ts
95 */
96export default defineCachedEventHandler(
97 async event => {
98 // Parse: [pkg, 'v', version, ...filePath] or [@scope, pkg, 'v', version, ...filePath]
99 const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? []
100
101 const { rawPackageName, rawVersion: fullPathAfterV } = parsePackageParams(pkgParamSegments)
102
103 // Since version AND path route are required, we split the remainder
104 // fullPathAfterV => "1.2.3/dist/index.mjs"
105 const versionSegments = fullPathAfterV?.split('/') ?? []
106
107 if (versionSegments.length < 2) {
108 throw createError({
109 // TODO: throwing 404 rather than 400 as it's cacheable
110 statusCode: 404,
111 message: ERROR_PACKAGE_VERSION_AND_FILE_FAILED,
112 })
113 }
114
115 // The version is the first segment after 'v', and everything else is the file path
116 const rawVersion = versionSegments[0]
117 const rawFilePath = versionSegments.slice(1).join('/')
118
119 try {
120 const { packageName, version, filePath } = v.parse(PackageFileQuerySchema, {
121 packageName: rawPackageName,
122 version: rawVersion,
123 filePath: rawFilePath,
124 })
125
126 const content = await fetchFileContent(packageName, version, filePath)
127 const language = getLanguageFromPath(filePath)
128
129 // For JS/TS files, resolve dependency versions and relative imports for linking
130 let dependencies: Record<string, { version: string }> | undefined
131 let resolveRelative: ((specifier: string) => string | null) | undefined
132
133 if (IMPORT_LANGUAGES.has(language)) {
134 // Fetch package.json and file tree in parallel
135 const [pkgJson, fileTreeResponse] = await Promise.all([
136 fetchPackageJson(packageName, version),
137 getPackageFileTree(packageName, version).catch(() => null),
138 ])
139
140 // Resolve npm dependency versions
141 if (pkgJson) {
142 // Merge all dependency types
143 const allDeps: Record<string, string> = {
144 ...pkgJson.dependencies,
145 ...pkgJson.peerDependencies,
146 ...pkgJson.optionalDependencies,
147 // Note: excluding devDependencies as they're less likely to be imported in dist files
148 }
149
150 if (Object.keys(allDeps).length > 0) {
151 const resolved: Record<string, string> = await resolveDependencyVersions(allDeps)
152 dependencies = {}
153 for (const [name, ver] of Object.entries(resolved)) {
154 dependencies[name] = { version: ver }
155 }
156 }
157 }
158
159 // Create resolver for relative imports
160 if (fileTreeResponse) {
161 const files = flattenFileTree(fileTreeResponse.tree)
162 resolveRelative = createImportResolver(files, filePath, packageName, version)
163 }
164 }
165
166 const html = await highlightCode(content, language, {
167 dependencies,
168 resolveRelative,
169 })
170
171 let markdownHtml: ReadmeResponse | undefined
172 if (language === 'markdown') {
173 // Best-effort: markdown preview is optional; never block code view
174 try {
175 const packageData = await fetchNpmPackage(rawPackageName)
176 const repoInfo = parseRepositoryInfo(packageData.repository)
177 markdownHtml = await renderReadmeHtml(content, rawPackageName, repoInfo)
178 } catch {
179 markdownHtml = undefined
180 }
181 }
182
183 return {
184 package: packageName,
185 version,
186 path: filePath,
187 language,
188 content,
189 html,
190 lines: content.split('\n').length,
191 markdownHtml,
192 }
193 } catch (error: unknown) {
194 handleApiError(error, {
195 statusCode: 502,
196 message: 'Failed to fetch file content',
197 })
198 }
199 },
200 {
201 // File content for a specific version never changes - cache permanently
202 maxAge: CACHE_MAX_AGE_ONE_YEAR, // 1 year
203 getKey: event => {
204 const pkg = getRouterParam(event, 'pkg') ?? ''
205 return `file:v${CACHE_VERSION}:${pkg.replace(/\/+$/, '').trim()}`
206 },
207 },
208)